今天,終於是工具部分的最後一篇了,讓我們繼續完成最後兩個工具吧!
很多的操作都可以用終端的指令來完成,賦予 Agent 執行 Shell 指令的能力,能大幅拓展其自主解決問題的範圍,我們來實作看看吧!
這裡參數只有一個,也就是要傳入終端的指令:
# src/meowgent/tool.py
import subprocess
...
@tool_register(True) # 需要審核
def run_shell(
command: Annotated[str, "要在系統 Shell 執行的指令"]
) -> str:
""" 執行終端指令 """
這裡直接用 subprocess.run() 運行指令:
# src/meowgent/tool.py
...
@tool_register(...)
def run_shell(...) -> str:
""" ... """
try:
execution = subprocess.run(
command,
shell=True,
capture_output=True, # 回傳輸出或錯誤
text=True, # 轉字串
timeout=30, # 超過 30 秒則退出
errors="replace" # 防止解碼報錯
)
shell=True我們仔細來說一下:
首先,解釋一下終端跟 Shell 的差別:
前者只是一個使用者介面,它只負責輸入及輸出,而 Shell 才是那個讀懂指令的「大腦」,也就是說在終端機視窗裡打字,但真正負責看懂指令並執行的是 Shell。而
shell原本預設是 False,改成 True 後簡單來說就是「讓 Python 透過系統 Shell(如 bash、zsh 或 cmd)去解析整行指令,能使用管道|與萬用字元*,但若拼接外部字串會帶來命令注入的資安風險(惡意代碼夾帶在字串裡)」。
這裡有個問題,「透過 Shell 解析」是什麼意思,不然要透過什麼?我們先了解一下「指令」到底是什麼?本質上它就是存在你電腦裡的「執行檔」,而這個執行檔,由系統核心(Kernel)直接執行,而「Shell」只是一個負責看懂你輸入的東西(也就是解讀語法,例如,在兩條指令中間加上
|(管道)可以把前者執行的輸出給後者做輸入),並轉成給核心的指令而已。
在這裡,設定的shell=True代表指令會交由 Shell 解析完整語法,因此可以直接傳入包含複雜語法的字串指令。那上面感覺都在說
shell=True不好,為什麼這裡還這麼做呢?
最主要就是因為模型常會用「包含語法」的指令做事,而這裡的資安風險,將透過我們後續加入的「人工審核機制」進行防範與把關。
接著,來解析指令回傳結果:
如果 .returncode 是 0 代表有成功執行,查看是否有回傳東西(沒回傳東西自己補上(指令執行完成,無輸出內容));反之則回報失敗狀況。
# src/meowgent/tool.py
...
@tool_register(...)
def run_shell(...) -> str:
""" ... """
try:
...
if execution.returncode == 0: # 成功執行
return execution.stdout if execution.stdout != "" else "
(指令執行完成,無輸出內容)
"
else:
return f"指令執行失敗(結束代碼 {execution.returncode}):\n
標準輸出 (Stdout):{execution.stdout}\n
標準錯誤 (Stderr):{execution.stderr}"
最後,加入指令逾時以及其他的例外捕捉:
# src/meowgent/tool.py
...
@tool_register(...)
def run_shell(...) -> str:
""" ... """
try:
...
except subprocess.TimeoutExpired: # 攔截超時
return f"錯誤:指令 '{command}' 執行超時(超過 30 秒)。"
except Exception as e:
return f"錯誤:執行指令時發生錯誤:{e}"
模型的知識僅止於訓練資料獲取的當下,在資訊瞬息萬變的時代,訓練當時的資料可能都已經過時了,這時就需要「網頁抓取」功能!
而為什麼是說網頁抓取呢?這是因為這個工具本質上其實就只是一個「純文字的 HTTP 下載器」,而不是一個完整的瀏覽器,那麼,讓我們開始最後一個工具的製作吧!
這裡參數部分除了網址,同樣也需要 offset 和 limit 對上下文做限制:
# src/meowgent/tool.py
...
import httpx
from readability import Document
import html2text
...
@tool_register(True) # 需要審核
def web_fetch(
url: Annotated[str, "要抓取內容的網頁網址(必須以 http:// 或 https:// 開頭)"],
offset: Annotated[int, "讀取內容的起始字元偏移量(預設為 0,用於分頁讀取長網頁)"] = 0,
limit: Annotated[int, "本次讀取的最大字元數量(預設為 3000)"] = 3000
) -> str:
""" 獲取網頁內容並轉成文字 """
先驗證確認是網址:
# src/meowgent/tool.py
...
@tool_register(...)
def web_fetch(...) -> str:
""" ... """
if not url.startswith(("http://", "https://")): # 非網址
return "錯誤:這不是一個有效的網址(URL)"
爬蟲時,需要做一個標頭字典 headers 來繞過反爬蟲,接著就可以 httpx.get() 來獲取內文了,
內文在 content.decode() 裡,將其取出放入 text(採用請求獲取的編碼或 "utf-8",errors="ignore" 防止編碼造成報錯),
下面還需要加上 httpx.get() 的例外捕捉。
response.raise_for_status()為了捕捉連線異常httpx.HTTPStatusError。
# src/meowgent/tool.py
...
@tool_register(...)
def web_fetch(...) -> str:
...
headers = {
"User-Agent": "
Mozilla/5.0 (Windows NT 10.0; Win64; x64)
AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/120.0.0.0 Safari/537.36
"
} # 偽裝為用戶,繞過反爬蟲
try:
response = httpx.get(
url = url, # 網址
headers=headers, # 請求的標頭字典(上方設定的)
follow_redirects=True, # 301 / 302 重定向轉址(防網址搬家)
timeout=10 # 逾時限制
)
response.raise_for_status() # 拋出連線異常 httpx.HTTPStatusError
text = response.content.decode(
response.encoding or "utf-8",
errors="ignore"
)
except httpx.HTTPStatusError as e: # 攔截伺服器錯誤
return f"
錯誤:HTTP 狀態碼 {e.response.status_code} ({e.response.reason_phrase})
"
except httpx.RequestError as e: # 攔截網路連線錯誤
return f"錯誤:網路連線失敗:{e}"
網頁提取下來後,會有很多無關緊要的內容,例如頂部導覽頁、頁尾、甚至是廣告,我們將其做刪減,縮減上下文壓力:
這裡利用 Document(text).summary() 做到,並且後面加上「確保內容沒有被過度刪減」的邏輯。
# src/meowgent/tool.py
...
@tool_register(...)
def web_fetch(...) -> str:
...
html_summary = Document(text).summary()
if html_summary and len(html_summary) > 100: # 存在且沒有過度刪減
text = html_summary
網頁用的 HTML 有很多不必要的標籤,如果直接給模型,同樣會浪費 token,我們要將其轉為 Markdown 來做優化:
首先,先 html2text.HTML2Text() 接著對其做設定,最後 .handle() 做轉換。
# src/meowgent/tool.py
...
@tool_register(...)
def web_fetch(...) -> str:
...
h = html2text.HTML2Text()
h.ignore_images = True # 忽略圖片
h.body_width = 0 # 不做自動換行
h.single_line_break = True # 換行不隔行 -> 避免 .md 格式的兩行之間隔一行
text = h.handle(text)
還是老話一句,節省上下文,提取出來的內容我們還得做切片,先來定位切片位置:
offset 不能比提取出來的內容長(起點不能比全文長)。raw_end(結尾)比全文長則結尾為全文尾。else 裡,是為了不要讓內容斷在一半:.rfind())換行("\n\n")作為自然的結束點,target_idx 的結果(也就是 .rfind())的回傳為 -1,則代表沒有搜尋到斷點,只好用原先的 raw_end。# src/meowgent/tool.py
...
@tool_register(...)
def web_fetch(...) -> str:
...
total_len = len(text)
if offset > total_len: # 起始點大於總文長
return f"錯誤:指定的 offset ({offset}) 超出網頁內容總長度 ({total_len})"
raw_end = offset + limit
if raw_end >= total_len:
end = total_len
else: # 讓內容不斷在一半
search_start = max(offset, raw_end - 500) # 往回推 500 為搜尋邊界
target_idx = text.rfind("\n\n", search_start, raw_end)
# 找最後出現的,故從右開始找
end = target_idx if target_idx != -1 else raw_end
接著把回傳搞定:
# src/meowgent/tool.py
...
@tool_register(...)
def web_fetch(...) -> str:
...
suffix = f"
\n\n[內容已截斷。若要閱讀下一頁,請呼叫 web_fetch 並帶入 offset={end}]
" if total_len > end else ""
return f"
[顯示字元區間 {offset}~{end},總字數為 {total_len}]\n
" + text[offset:end] + suffix
Day 2 到今天 Day 4 這篇,我們完成了最簡單的讀寫、讓 Agent 可以看到檔案、檔案內部搜尋、終端、網路,六個工具函數了,這麼一來,基礎的工具我們都已經完成了!
而其實,還有非常多的工具可以做實作,像是 PDF 檔的讀取工具等等的,有興趣的可以自己試著做做看~
接著下一篇,來嘗試把工具給接入模型吧!